The Testing Reality Check
I used to write tests for everything. Then I realized most of those tests didn't catch bugs—they just slowed me down. Here's what I learned about testing strategies that actually matter.
The Testing Pyramid
Unit Tests (Base - Most)
Fast, isolated tests for individual functions/components:
// math.utils.test.js
import { calculateTotal, applyDiscount } from "./math.utils"
describe("calculateTotal", () => {
it("should sum all prices", () => {
const items = [{ price: 10 }, { price: 20 }, { price: 30 }]
expect(calculateTotal(items)).toBe(60)
})
it("should handle empty array", () => {
expect(calculateTotal([])).toBe(0)
})
it("should handle negative prices", () => {
const items = [{ price: -10 }, { price: 20 }]
expect(calculateTotal(items)).toBe(10)
})
})
describe("applyDiscount", () => {
it("should apply percentage discount", () => {
expect(applyDiscount(100, 10)).toBe(90)
})
it("should handle 0 discount", () => {
expect(applyDiscount(100, 0)).toBe(100)
})
it("should handle 100% discount", () => {
expect(applyDiscount(100, 100)).toBe(0)
})
})Integration Tests (Middle)
Test how different parts work together:
// user.service.test.js
import { UserService } from "./user.service"
import { db } from "./db"
import { emailService } from "./email.service"
jest.mock("./db")
jest.mock("./email.service")
describe("UserService", () => {
it("should create user and send welcome email", async () => {
const mockUser = { id: 1, email: "test@example.com" }
db.user.create.mockResolvedValue(mockUser)
emailService.send.mockResolvedValue(true)
const user = await UserService.createUser({
email: "test@example.com",
name: "Test User",
})
expect(db.user.create).toHaveBeenCalledWith({
data: {
email: "test@example.com",
name: "Test User",
},
})
expect(emailService.send).toHaveBeenCalledWith({
to: "test@example.com",
subject: "Welcome!",
template: "welcome",
})
expect(user).toEqual(mockUser)
})
})E2E Tests (Top - Fewest)
Test complete user flows:
// user-registration.e2e.test.js
import { test, expect } from "@playwright/test"
test("user can register and login", async ({ page }) => {
// Register
await page.goto("/register")
await page.fill('[name="email"]', "test@example.com")
await page.fill('[name="password"]', "password123")
await page.fill('[name="name"]', "Test User")
await page.click('button[type="submit"]')
// Should redirect to login
await expect(page).toHaveURL("/login")
// Login
await page.fill('[name="email"]', "test@example.com")
await page.fill('[name="password"]', "password123")
await page.click('button[type="submit"]')
// Should be logged in
await expect(page.locator("text=Welcome, Test User")).toBeVisible()
})What to Test (And What Not To)
Test Business Logic
// Business logic should be tested
function calculateShippingCost(order) {
if (order.total > 100) return 0 // Free shipping over $100
if (order.items.some(item => item.weight > 10)) return 15 // Heavy items
return 5 // Standard shipping
}
// Test it!
describe("calculateShippingCost", () => {
it("should provide free shipping for orders over $100", () => {
const order = { total: 101, items: [] }
expect(calculateShippingCost(order)).toBe(0)
})
it("should charge $15 for heavy items", () => {
const order = { total: 50, items: [{ weight: 15 }] }
expect(calculateShippingCost(order)).toBe(15)
})
it("should charge $5 for standard orders", () => {
const order = { total: 50, items: [{ weight: 5 }] }
expect(calculateShippingCost(order)).toBe(5)
})
})Test Edge Cases
// Always test edge cases
function divide(a, b) {
if (b === 0) throw new Error("Cannot divide by zero")
return a / b
}
describe("divide", () => {
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero")
})
it("should handle negative numbers", () => {
expect(divide(-10, 2)).toBe(-5)
})
it("should handle decimal results", () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 3)
})
})Don't Test Implementation Details
// Testing implementation details (not recommended)
it("should call setState with new count", () => {
const setState = jest.fn()
incrementCount(setState)
expect(setState).toHaveBeenCalledWith(expect.any(Function))
})
// Testing behavior instead
it("should increment count", () => {
const [count, setCount] = useState(0)
incrementCount(setCount)
expect(count).toBe(1)
})Don't Test Third-Party Code
// Testing library code directly (not necessary)
it("should format date with moment", () => {
const date = moment("2023-01-01")
expect(date.format("YYYY-MM-DD")).toBe("2023-01-01")
})
// Test your usage of it instead
it("should display formatted date", () => {
const formatted = formatUserDate("2023-01-01")
expect(formatted).toBe("Jan 1, 2023")
})React Component Testing
Testing User Interactions
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { Counter } from "./Counter"
describe("Counter", () => {
it("should increment when button is clicked", async () => {
render(<Counter />)
const button = screen.getByRole("button", { name: /increment/i })
const count = screen.getByText(/count: 0/i)
expect(count).toBeInTheDocument()
fireEvent.click(button)
await waitFor(() => {
expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})
})
it("should handle async actions", async () => {
render(<UserProfile userId="123" />)
// Wait for loading to finish
await waitFor(() => {
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
})
})
})Testing Hooks
import { renderHook, act } from "@testing-library/react"
import { useCounter } from "./useCounter"
describe("useCounter", () => {
it("should increment count", () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})API Testing
Mocking API Calls
// Mock fetch globally
global.fetch = jest.fn()
describe("fetchUser", () => {
it("should fetch and return user data", async () => {
const mockUser = { id: 1, name: "John Doe" }
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
})
const user = await fetchUser(1)
expect(fetch).toHaveBeenCalledWith("/api/users/1")
expect(user).toEqual(mockUser)
})
it("should handle errors", async () => {
fetch.mockRejectedValueOnce(new Error("Network error"))
await expect(fetchUser(1)).rejects.toThrow("Network error")
})
})Testing Best Practices
1. Test Behavior, Not Implementation
// Testing implementation details (not recommended)
it("should call API service with correct parameters", () => {
// Testing implementation details
})
// Testing behavior instead
it("should display user data after loading", async () => {
// Testing what user sees
})2. Use Descriptive Test Names
// Unclear test names
it("test 1", () => {})
it("works", () => {})
// Descriptive test names
it("should calculate total price including tax", () => {})
it("should show error message when email is invalid", () => {})3. Arrange-Act-Assert Pattern
it("should add item to cart", () => {
// Arrange - set up test data
const cart = new Cart()
const product = { id: 1, name: "Product", price: 10 }
// Act - perform the action
cart.addItem(product)
// Assert - verify the result
expect(cart.items).toHaveLength(1)
expect(cart.items[0]).toEqual(product)
})4. Keep Tests Independent
// Tests that depend on each other (problematic)
let counter = 0
it("should set counter to 1", () => {
counter = 1
expect(counter).toBe(1)
})
it("should increment counter", () => {
counter++ // Depends on previous test!
expect(counter).toBe(2)
})
// Each test should be independent
it("should set counter to 1", () => {
const counter = new Counter()
counter.setValue(1)
expect(counter.value).toBe(1)
})
it("should increment counter", () => {
const counter = new Counter()
counter.setValue(1)
counter.increment()
expect(counter.value).toBe(2)
})When to Write Tests
Write Tests For:
- Business logic and calculations
- Complex conditional logic
- User-facing features
- Critical paths (payment, auth, data)
- Bug fixes (regression tests)
Skip Tests For:
- Simple getters/setters
- Third-party library code
- Configuration files
- Static content
- Trivial utilities
Test Coverage Reality
// Aim for high coverage on critical code
// But don't chase 100% coverage - it's not worth it
// Critical functions: 90%+ coverage
// Utilities: 70-80% coverage
// Components: 60-70% coverage (focus on interactions)
// E2E: Cover main user flows onlyWhat I Learned
- Test what matters: Business logic, edge cases, user flows
- Don't test everything: Some code doesn't need tests
- Focus on behavior: What users see, not implementation details
- Write tests that fail: Tests that always pass aren't useful
- Tests are documentation: Good tests explain how code should work
- Balance speed and coverage: Fast tests > comprehensive tests
The key insight: Good tests prevent bugs and give confidence. Bad tests slow you down without adding value. Know the difference.